基于 Puppeteer 与 Chrome Trace 的前端性能自动化测量工具

发布于 | 分类于 前端/前端工程|本文包含AIGC内容

复杂前端应用的性能问题,往往不发生在首屏加载阶段,而是发生在页面已经运行之后:大批量数据进入、组件持续更新、列表滚动、图表刷新或用户连续操作时,页面开始出现 CPU 占用升高、内存增长、掉帧和响应变慢。

Chrome DevTools 很适合定位一次具体问题,但手工录制难以稳定复现相同场景,也不方便比较两个版本。要让性能评测可以重复执行,需要把浏览器启动、场景操作、性能采集、指标解析和结果对比串成一条自动化链路。

本文整理一种基于 Puppeteer、Chrome Trace Event 和 Chrome DevTools Protocol(下文简称 CDP)的实现思路,重点讨论工具的边界、模块设计和指标提取规则,不涉及任何具体业务代码。

一、先明确测量对象

“页面性能”不是一个单一指标。工具设计前,应先区分三类问题:

类型典型指标常用数据源
加载性能FCP、LCP、CLS、资源加载耗时Navigation Timing、Resource Timing、Web Vitals、Lighthouse
运行时性能主线程忙碌率、长任务、帧间隔、操作响应时间Chrome Trace、自定义埋点、Event Timing
系统资源浏览器进程 CPU、进程内存、GPU 使用操作系统进程监控、Chrome 任务管理器

本文关注第二类,即一个已经进入可操作状态的页面,在指定操作窗口内的运行表现。

这里有一个容易混淆的边界:从 Trace 中统计主线程任务耗时,得到的是“渲染主线程忙碌率”,不是操作系统意义上的 CPU 占用率。页面还可能使用 Worker、GPU 进程、网络进程和其他渲染线程,因此不能用这个数推导整台机器或整个浏览器使用了多少个 CPU 核心。

如果目标是系统级 CPU,需要先定位页面对应的渲染进程,再通过系统进程监控采样;它应当作为另一套指标单独记录,不能和主线程忙碌率混用。

二、工具的整体设计

一套可重复使用的测量工具,可以拆成五层:

mermaid
flowchart LR
    A[场景定义] --> B[Puppeteer 执行器]
    B --> C[Trace / CDP / 自定义埋点]
    C --> D[事件解析与指标聚合]
    D --> E[本地测试记录]
    E --> F[详情查看与版本对比]

2.1 场景定义

场景层只描述“如何把页面带到可测状态”和“执行什么操作”,不关心指标如何计算。通常需要提供几个扩展点:

  • install:页面加载前注入通用脚本、设置测试环境。
  • prepare:等待页面达到稳定、可操作状态。
  • actions:执行点击、滚动、输入或一段持续操作。
  • after:等待异步更新收敛并收集自定义埋点。

把场景和采集器分开有两个好处:同一套指标解析逻辑可以复用到不同页面;页面结构变化时,只需要调整场景,不需要修改底层采集代码。

2.2 Puppeteer 执行器

执行器负责浏览器生命周期和测量窗口,主流程可以概括为:

  1. 使用固定版本的 Chrome 和固定窗口尺寸启动浏览器。
  2. 创建或复用一个页面,完成测试前初始化。
  3. 打开目标页面,等待明确的就绪条件。
  4. 开启 Trace 和其他采集器。
  5. 执行测试动作。
  6. 等待必要的收尾时间,停止采集。
  7. 解析数据、保存本地记录并关闭浏览器。

性能采集不应覆盖登录、页面跳转和不相关的等待时间。更可靠的方式是先让页面进入可测状态,再显式开启 Trace;动作完成并且界面稳定后立即停止。这样每次测量的时间窗口才具有可比性。

执行器还应使用 try/finally 保证异常情况下能够停止采集和关闭浏览器,避免留下损坏的 Trace 文件或后台进程。

2.3 三种互补的数据源

单一数据源很难覆盖所有问题,因此工具同时使用三种采集方式:

数据源适合回答的问题特点
Chrome Trace主线程何时繁忙、是否有长任务、帧事件如何分布信息最完整,文件较大,解析成本高
CDP Performance.getMetricsDOM 节点、事件监听器、累计脚本和布局耗时适合低频采样,但多数计时字段是累计值
User Timing某个业务阶段从开始到可见完成用了多久语义明确,但需要在正确的完成时机埋点

三者的时间口径必须对齐到同一个测量窗口。否则 Trace 记录了 30 秒,CDP 采样了 60 秒,自定义埋点又跨越了页面初始化阶段,最后得到的数字无法相互解释。

2.4 本地结果模型

每次测试可以在独立目录中生成三类文件:

  • trace.json:原始 Trace,供 DevTools 或 Perfetto 继续下钻。
  • summary.json:解析后的汇总指标和必要的时间序列。
  • records.json:测试记录索引,保存时间、场景、环境和结果文件的相对路径。

记录使用相对路径,整个结果目录就可以在本地移动。可视化层只读取这些静态 JSON,展示单次详情和两组结果的变化,不需要引入服务端。

三、理解 Trace Event Format

Puppeteer 的 tracing 接口最终生成 Chrome Trace Event Format。解析器主要关心以下字段:

字段含义
name事件名称,例如 RunTaskDrawFrameUpdateCounters
cat事件所属分类
ph事件阶段或类型
ts事件开始时间,通常以微秒表示
dur完整事件持续时间,通常以微秒表示
pid进程 ID
tid线程 ID
args与事件相关的附加数据

常见的 ph 类型包括:

  • X:完整事件,开始时间和持续时间在同一条记录中。
  • B / E:一对开始、结束事件,需要按进程、线程和嵌套关系配对。
  • I:瞬时事件,只有发生时间。
  • M:元数据,例如线程名称。

指标解析前应先做两件事:

  1. 统一时间单位。Trace 通常使用微秒,而文章和报表一般使用毫秒。
  2. 锁定进程与线程。不同线程可能出现同名事件,只按 name 筛选会把不相关的数据混在一起。

Trace 的事件名称和类别不是稳定的 Web 标准 API,可能随 Chrome 版本变化。因此自动化基准必须固定浏览器版本,并保留原始 Trace;升级浏览器后,需要重新验证筛选规则。

四、确定测量时间范围与主线程

4.1 时间范围

最理想的时间范围来自显式的采集开始和结束时刻。若只能从事件反推,则可以取最早事件的 ts 作为开始,取最大的 ts + dur 作为结束。

但是,直接使用整个 Trace 的最小和最大时间戳可能包含初始化、元数据或停止采集时的额外事件。因此,解析器最好把测试动作对应的标记也写入 Trace,并以这对标记裁剪数据。

4.2 主线程识别

主渲染线程通常可以从 thread_name 元数据中识别,其名称常见为 CrRendererMain。找到它的 pidtid 后,主线程忙碌率和长任务都只在这个范围内统计。

当 Trace 中存在多个渲染进程时,仅取第一个同名线程并不可靠。更完整的实现还应结合目标页面的 frame、进程切换记录或导航事件,确认哪个渲染进程属于被测页面。

五、指标提取规则

5.1 主线程忙碌率

主线程忙碌率用于描述一个时间窗口内,主线程有多少时间在执行顶层任务。

首先把测量窗口切成固定长度的时间片,例如 200ms。对主线程上的完整 RunTask 事件,计算它与每个时间片的交集:

text
重叠时间 = max(0, min(任务结束, 分片结束) - max(任务开始, 分片开始))

分片忙碌率 = 分片内任务占用时间 / 分片实际长度 × 100%

一个 350ms 的任务可能跨越多个分片,因此不能只把它记到开始时间所在的分片中。

只统计顶层 RunTask,是为了避免重复累加它内部的 FunctionCallLayoutPaint 等子事件。若事件本身可能重叠,应先对时间区间求并集再求和;简单相加后强制截断到 100%,会掩盖重复计数问题。

分片大小决定了结果的观察尺度:

  • 分片过小,峰值更明显,但噪声和数据量更大。
  • 分片过大,曲线更平滑,但短时间卡顿容易被平均掉。
  • 200ms 可以作为初始值,不应被视为通用标准。

汇总时建议至少保留平均值、中位数、P95 和最大值。平均值表示整体负载,中位数表示常态,P95 和最大值用于观察尾部峰值。

5.2 使用 CDP 采样主线程忙碌率

Performance.getMetrics 中的 TaskDurationScriptDurationLayoutDurationRecalcStyleDuration 是自启用监控以来的累计秒数,不是某一时刻的比例。

因此不能直接用 TaskDuration × 100% 作为 CPU 指标。相邻两次采样之间的近似忙碌率应按增量计算:

text
忙碌率 = ΔTaskDuration / ΔTimestamp × 100%

累计字段还可以计算脚本、布局和样式重算在采样区间内的耗时增量。它们适合作为趋势监控和辅助解释,Trace 则更适合分析具体事件。

5.3 长任务

浏览器主线程连续执行超过 50ms,会长期占据主线程并压缩处理输入和渲染的时间。基于 Trace 的一种实用近似规则是:

text
事件属于目标页面的主渲染线程
并且事件类型为完整事件
并且事件名称为顶层 RunTask
并且 dur >= 50ms

建议输出:

  • 长任务数量。
  • 总阻塞时长。
  • 平均、中位数、P95 和最大持续时间。
  • 每个长任务的开始时间和持续时间,便于回到 Trace 定位。

还可以计算近似的 Total Blocking Time:对每个长任务只累计超过 50ms 的部分。

text
TBT ≈ Σ max(0, 任务持续时间 - 50ms)

需要注意,这种 RunTask 筛选是工程上的近似口径,并不完全等价于 Long Tasks API 的任务归因和上下文定义。报告应明确写出提取规则,而不是只写“长任务来自 Chrome 标准”。

5.4 帧间隔、FPS 与掉帧

一种简单做法是读取连续 DrawFrame 瞬时事件,用相邻时间戳之差计算帧间隔:

text
frameInterval[i] = drawFrame[i].ts - drawFrame[i - 1].ts
近似平均 FPS = 1000 / 平均帧间隔(毫秒)

但这个算法有明确适用条件:测试场景必须持续发生视觉更新。静态页面不会为了满足统计而持续产生有效绘制事件,此时较长的事件间隔并不表示页面一直在低帧率运行。

因此更稳妥的报告方式是优先输出帧间隔分布:平均值、P95、最大值,以及超过帧预算的次数。帧预算由测试环境的目标刷新率决定:

text
帧预算 = 1000 / 目标刷新率

60Hz 环境的帧预算约为 16.67ms,120Hz 环境约为 8.33ms。不能把 60 FPS 永久写死为所有设备的上限。

DrawFrame 也不能完整表达 Chrome DevTools 中的 dropped frame、partially presented frame 等状态。若目标是精确复现 DevTools Frames 轨道,需要解析更完整的 BeginFrame、提交、合成和呈现事件,而不是只根据两个 DrawFrame 的间隔下结论。

5.5 JS 堆内存

Trace 中的 UpdateCounters 事件可能包含:

  • jsHeapSizeUsed:已使用的 JS 堆大小。
  • jsHeapSizeLimit:JS 堆上限或相关容量值。
  • DOM 节点等计数器。

这类事件通常是稀疏采样,不会在每个时间片出现。可以将最近一次有效值前向填充到后续分片,再计算最大值和基于采样窗口的平均值。

内存指标应至少保留三类结果:

  • 起始值、结束值以及净增长量。
  • 测量窗口内的最大值。
  • 分片序列,用于观察持续增长还是操作后的正常回落。

单次测试中的“结束值更大”不能直接证明内存泄漏。垃圾回收时间不可控,缓存也可能是预期行为。判断泄漏更适合重复执行同一操作,并在多轮操作后观察基线是否持续抬升;必要时结合 Heap Snapshot 验证对象是否仍被引用。

5.6 DOM、事件监听与渲染计数

CDP 的 Performance.getMetrics 还可以提供一些结构性指标:

指标解释方式
Nodes当前 DOM 节点数量,适合观察是否持续增长
JSEventListeners已注册事件监听器数量,适合发现重复绑定趋势
LayoutCount累计布局次数,应使用相邻采样差值
RecalcStyleCount累计样式重算次数,应使用相邻采样差值
LayoutDuration累计布局耗时,应使用相邻采样差值
RecalcStyleDuration累计样式重算耗时,应使用相邻采样差值
ScriptDuration累计脚本执行耗时,应使用相邻采样差值

这些数字通常用于解释主指标,而不是单独作为性能结论。例如,一段操作的主线程忙碌率升高,同时布局次数和布局耗时大幅增加,才比较有理由继续调查布局抖动。

5.7 自定义阶段耗时

Trace 能说明浏览器在忙什么,但不知道业务上“什么时候算完成”。例如一次操作可能在点击后先请求数据,再更新状态,最后等到 DOM 绘制完成才算对用户可见。

这类指标适合使用 User Timing API:

js
performance.mark(`${id}:start`)
// 执行被测操作
performance.mark(`${id}:end`)
performance.measure(name, `${id}:start`, `${id}:end`)

工具层应为每次调用生成唯一 ID,而不是只用名称和自增下标猜测配对关系。这样才能正确处理同名操作重入、并发执行、异常退出和未闭合测量。

结束点也必须对应用户真正感知到的完成时刻。只在 Promise 返回时结束,可能遗漏框架提交更新和浏览器绘制;只使用固定延迟,又会把无关等待算入结果。可以根据场景选择 DOM 状态、下一次绘制或应用提供的完成事件作为结束条件。

同名测量聚合时,建议记录 count、平均值、中位数、P95 和最大值,同时保留原始样本。样本数量太少时,不应只展示百分比变化。

六、为什么既保留汇总,也保留原始数据

汇总指标便于建立基线和做版本对比,但会丢失时间上下文。两个版本的平均主线程忙碌率可能相同,其中一个版本却出现了单次 800ms 的长任务。

因此结果模型最好分成三层:

  1. 汇总层:用于列表展示和门禁判断。
  2. 时间序列层:用于定位峰值发生在哪个时间段。
  3. 原始 Trace:用于在 DevTools 或 Perfetto 中继续分析调用栈和事件关系。

对比界面中的百分比变化可以按下面的方式计算:

text
变化率 = (当前值 - 基线值) / |基线值| × 100%

当基线为 0、样本量不同或测试条件不一致时,变化率可能没有意义,界面应显示“不可比较”,而不是给出一个看似精确的百分比。

七、让测试结果可比较

自动化只解决“能重复执行”,不自动保证“结果可信”。建立基线时至少要控制以下变量:

  • 固定 Chrome 大版本、操作系统和硬件。
  • 固定窗口尺寸、设备缩放和目标刷新率。
  • 固定数据集、操作顺序、操作次数和测量时长。
  • 明确缓存策略,并在所有轮次中保持一致。
  • 禁用会干扰结果的扩展,减少无关后台程序。
  • 保持页面位于前台,避免后台标签页的节流和冻结策略。
  • 预热后再测量,避免首次编译、缓存建立等一次性成本污染结果。
  • 同一版本运行多轮,使用中位数或分位数作为版本结果。

建议先完成若干轮预热,再执行至少 5~10 轮正式测量。性能门禁应同时设置绝对阈值和相对退化阈值,并要求退化在多轮结果中稳定出现,避免因为一次系统抖动误报。

八、实现时值得注意的问题

8.1 Trace 文件体积

记录类别越多、时间越长,Trace 文件越大。工具应只覆盖必要的测量窗口,并根据待分析指标选择 tracing categories。截图轨道对本文这些汇总指标不是必需项,关闭后可以明显减少文件体积。

8.2 解析复杂度

如果为每个事件遍历全部时间片,复杂度会随着事件数和分片数相乘。更高效的方式是根据事件开始、结束时间直接计算它覆盖的分片索引,只处理相关分片。

当 Trace 很大时,也不宜一次性将完整 JSON 常驻内存。可以考虑流式解析、分阶段提取,或者借助 Trace Processor 查询所需事件。

8.3 数据类型

报表展示可以格式化为 15.2 MB23.4%,但持久化模型应保留原始数值和单位。否则可视化层只能从字符串中再次解析数字,容易出现比较和排序错误。

8.4 异常数据

解析器需要显式处理:

  • 找不到目标主线程。
  • 测量窗口为空或结束时间早于开始时间。
  • 没有 DrawFrame 或内存采样事件。
  • Trace 中途被截断。
  • 自定义埋点未闭合。
  • CDP 两次采样时间戳相同。

缺失数据应标记为 N/A 并附带原因,不应默认为 0。0 表示确实测量到了零,和没有采集到数据是两种完全不同的情况。

九、工具的适用边界

这套工具适合回答:

  • 某段固定交互在不同版本中的运行时性能是否退化。
  • 数据规模增加后,主线程、内存和渲染压力如何变化。
  • 一次优化是否减少了长任务或改善了尾部响应时间。
  • 性能峰值发生在哪个时间段,是否值得回到 Trace 深入定位。

它不适合直接替代:

  • Lighthouse 和 Web Vitals 对加载体验的评估。
  • 操作系统级进程 CPU、总内存和 GPU 监控。
  • Heap Snapshot 对内存泄漏引用链的分析。
  • Chrome DevTools 对调用栈、火焰图和具体源码位置的人工诊断。

自动化性能工具的价值,不是把所有性能问题压缩成一个分数,而是建立一套稳定、透明、可复现的测量口径:场景相同、窗口明确、规则可解释、原始数据可回溯。只有做到这些,不同版本之间的数字才真正具有比较价值。

十、用真实 API 串起完整流程

下面使用 Puppeteer、CDP 和浏览器原生 Performance API 组成一段端到端伪代码。它省略了配置校验、异常恢复和完整的统计函数,但所有浏览器调用都对应真实 API,可以直观看到各模块如何衔接。

js
import fs from 'node:fs/promises'
import puppeteer from 'puppeteer'

async function runPerformanceTest(url) {
  const browser = await puppeteer.launch({
    headless: false,
    channel: 'chrome',
    args: ['--enable-precise-memory-info'],
  })

  const page = await browser.newPage()
  await page.setViewport({ width: 1440, height: 900 })

  // 在应用脚本执行前安装通用测量入口。
  await page.evaluateOnNewDocument(() => {
    window.__measure = {
      start(name, id) {
        performance.mark(`${name}:${id}:start`)
      },
      end(name, id) {
        const start = `${name}:${id}:start`
        const end = `${name}:${id}:end`
        performance.mark(end)
        performance.measure(name, start, end)
      },
    }
  })

  try {
    await page.goto(url, { waitUntil: 'networkidle2' })
    await page.waitForSelector('[data-page-ready="true"]')

    const cdp = await page.createCDPSession()
    await cdp.send('Performance.enable')

    // CDP 指标是累计值,测量窗口两端各采样一次。
    const metricsBefore = toMetricMap(
      await cdp.send('Performance.getMetrics'),
    )

    await page.tracing.start({
      path: './trace.json',
      screenshots: false,
      categories: [
        'devtools.timeline',
        'disabled-by-default-devtools.timeline',
        'v8.execute',
      ],
    })

    // 场景代码只负责描述被测操作和完成条件。
    const measureId = crypto.randomUUID()
    await page.evaluate((id) => window.__measure.start('interaction', id), measureId)
    await page.click('[data-test="action"]')
    await page.waitForFunction(() => {
      return document.querySelector('[data-test="result"]')?.dataset.ready === 'true'
    })
    await page.evaluate((id) => window.__measure.end('interaction', id), measureId)

    const userTimings = await page.evaluate(() => {
      return performance.getEntriesByType('measure').map((entry) => ({
        name: entry.name,
        startTime: entry.startTime,
        duration: entry.duration,
      }))
    })

    await page.tracing.stop()

    const metricsAfter = toMetricMap(
      await cdp.send('Performance.getMetrics'),
    )
    await cdp.detach()

    const trace = JSON.parse(await fs.readFile('./trace.json', 'utf8'))
    const traceMetrics = parseTrace(trace.traceEvents)
    const cdpMetrics = calculateMetricDelta(metricsBefore, metricsAfter)

    return {
      traceMetrics,
      cdpMetrics,
      userTimings: aggregateByName(userTimings),
    }
  } finally {
    await browser.close()
  }
}

function toMetricMap(result) {
  return Object.fromEntries(
    result.metrics.map(({ name, value }) => [name, value]),
  )
}

function calculateMetricDelta(before, after) {
  const elapsed = after.Timestamp - before.Timestamp

  return {
    mainThreadBusyRate:
      elapsed > 0
        ? ((after.TaskDuration - before.TaskDuration) / elapsed) * 100
        : null,
    scriptDuration: after.ScriptDuration - before.ScriptDuration,
    layoutDuration: after.LayoutDuration - before.LayoutDuration,
    recalcStyleDuration:
      after.RecalcStyleDuration - before.RecalcStyleDuration,
    layoutCount: after.LayoutCount - before.LayoutCount,
    recalcStyleCount: after.RecalcStyleCount - before.RecalcStyleCount,
    jsHeapUsedSize: after.JSHeapUsedSize,
    nodes: after.Nodes,
    eventListeners: after.JSEventListeners,
  }
}

function parseTrace(events) {
  const mainThread = events.find((event) => {
    return event.ph === 'M'
      && event.name === 'thread_name'
      && event.args?.name === 'CrRendererMain'
  })

  if (!mainThread) {
    return { available: false, reason: 'main thread not found' }
  }

  const runTasks = events.filter((event) => {
    return event.ph === 'X'
      && event.name === 'RunTask'
      && event.pid === mainThread.pid
      && event.tid === mainThread.tid
  })

  const longTasks = runTasks
    .filter((event) => event.dur >= 50_000)
    .map((event) => ({
      startTime: event.ts / 1000,
      duration: event.dur / 1000,
      blockingTime: Math.max(0, event.dur / 1000 - 50),
    }))

  const heapSamples = events
    .filter((event) => event.name === 'UpdateCounters')
    .map((event) => ({
      timestamp: event.ts / 1000,
      used: event.args?.data?.jsHeapSizeUsed,
    }))
    .filter((sample) => Number.isFinite(sample.used))

  const frameTimestamps = events
    .filter((event) => event.name === 'DrawFrame' && event.ph === 'I')
    .map((event) => event.ts / 1000)

  return {
    available: true,
    mainThreadBusyRate: calculateBusyRate(runTasks),
    longTasks: summarize(longTasks.map((task) => task.duration)),
    totalBlockingTime: sum(longTasks.map((task) => task.blockingTime)),
    jsHeap: summarize(heapSamples.map((sample) => sample.used)),
    frameIntervals: summarize(diff(frameTimestamps)),
  }
}

这段流程中,page.tracing 保存可下钻的原始事件,Performance.getMetrics 提供测量窗口两端的累计计数,User Timing 则补充业务语义。calculateBusyRatesummarizeaggregateByNamediffsum 代表工具自身的纯计算函数,其规则分别对应前文的时间片、分位数、同名测量聚合和相邻帧间隔计算。

真实项目还应把开始 Trace、停止 Trace、关闭 CDP 会话和关闭浏览器分别放入可独立执行的清理逻辑中。上面的 finally 只保留了主干表达;如果点击或等待步骤抛出异常,仍应尝试停止正在进行的 Trace,以保证后续测试不会受到残留状态影响。

参考资料

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。